feat(deploy): production deployment setup and fixes
- Debian-based api image (bookworm-slim), docker/debian mirrors, prisma binaryTargets for openssl 3.0 - nginx: admin SPA under /admin, TLS via acme.sh (ZeroSSL) + auto-renewal cron, http->https redirect - prisma: add origin_goods.delisted migration, sync missing schema (good_image/tag_font_color/good_tags), fix users.createdAt Timestamptz - api: CORS wildcard reflection, helmet CORP cross-origin, price backfill in persistProductDetail, categoryIcon ancestor fallback, mediaByColor per-color gallery in public goods detail - admin: /admin base path (vite + router) - import-data.mjs: udt_name casting, serial sequence advance fix
This commit is contained in:
@@ -1,131 +1,131 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import FeatureCards from '../app/components/FeatureCards.vue';
|
||||
import StepProcess from '../app/components/StepProcess.vue';
|
||||
|
||||
const readComponent = (name: string) =>
|
||||
readFileSync(resolve(process.cwd(), `app/components/${name}.vue`), 'utf8');
|
||||
|
||||
describe('homepage navigation and carousel behavior', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('uses current-page login navigation for every homepage CTA', () => {
|
||||
const components = ['AppHeader', 'HeroBanner', 'StepProcess', 'CtaBanner'];
|
||||
|
||||
for (const component of components) {
|
||||
const source = readComponent(component);
|
||||
expect(source).not.toMatch(/target="_blank"[^>]*>\s*(登录|立即开始|免费试用|立即免费使用)/u);
|
||||
expect(source).toContain("window.location.href = 'https://inkpod.vip/user/login'");
|
||||
}
|
||||
});
|
||||
|
||||
it('advances the four-step content automatically', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('ref', ref);
|
||||
const wrapper = mount(StepProcess);
|
||||
|
||||
expect(wrapper.get('[data-testid="step-image-0"]').classes()).toContain('opacity-100');
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(wrapper.get('[data-testid="step-image-1"]').classes()).toContain('opacity-100');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('fills the active step progress line before advancing', () => {
|
||||
const source = readComponent('StepProcess');
|
||||
expect(source).toContain('step-progress');
|
||||
expect(source).toContain('animation: fill-step-progress 2s linear forwards');
|
||||
expect(source).toContain('@keyframes fill-step-progress');
|
||||
expect(source).toContain('to { width: 100%; }');
|
||||
expect(source).toContain('class="step-title text-xl lg:text-4xl"');
|
||||
expect(source).not.toContain('step-title text-xl font-bold');
|
||||
});
|
||||
|
||||
it('advances the feature content every two seconds', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('ref', ref);
|
||||
const wrapper = mount(FeatureCards);
|
||||
|
||||
expect(wrapper.get('[data-testid="feature-image-0"]').classes()).toContain('opacity-100');
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(wrapper.get('[data-testid="feature-image-1"]').classes()).toContain('opacity-100');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('keeps feature items aligned with the image and uses restrained titles', () => {
|
||||
const source = readComponent('FeatureCards');
|
||||
expect(source).toContain('feature-list');
|
||||
expect(source).toContain('feature-item-title text-xl lg:text-3xl');
|
||||
expect(source).toContain('grid-template-rows: repeat(4, minmax(0, 1fr))');
|
||||
});
|
||||
|
||||
it('runs the customer-case track as an automatic marquee', () => {
|
||||
const source = readComponent('CustomerCases');
|
||||
expect(source).toContain('animation: case-marquee 30s');
|
||||
expect(source).toContain('@media (prefers-reduced-motion: reduce)');
|
||||
});
|
||||
|
||||
it('keeps the hero visual as a capped image instead of a background', () => {
|
||||
const source = readComponent('HeroBanner');
|
||||
expect(source).toMatch(/<img[^>]+src="\/主视觉\.png"/u);
|
||||
expect(source).not.toMatch(/background(?:-image)?:\s*[^;]*主视觉\.png/u);
|
||||
expect(source).toContain('hero-visual');
|
||||
});
|
||||
|
||||
it('styles the hero emphasis and separates the three selling points', () => {
|
||||
const source = readComponent('HeroBanner');
|
||||
expect(source).toContain('hero-copy');
|
||||
expect(source).toContain('hero-gradient-text');
|
||||
expect(source).toContain('linear-gradient(90deg, #111827 0%, #111827 28%, #ff6800 100%)');
|
||||
expect(source).toContain('.hero-copy { align-items: flex-start; }');
|
||||
expect(source).toContain('hero-subtitle font-thin');
|
||||
expect(source).toContain('selling-point-separator');
|
||||
expect(source).toContain('aria-hidden="true">|</span>');
|
||||
});
|
||||
|
||||
it('loads the supplied PingFang SC font weights globally', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'app/assets/css/tailwind.css'), 'utf8');
|
||||
expect(css).toContain("font-family: 'PingFang SC'");
|
||||
expect(css).toContain("url('/fonts/pingfang-sc-regular.ttf')");
|
||||
expect(css).toContain("url('/fonts/pingfang-sc-thin.ttf')");
|
||||
expect(css).toContain("url('/fonts/pingfang-sc-medium.ttf')");
|
||||
expect(css).toContain("url('/fonts/pingfang-sc-semibold.ttf')");
|
||||
expect(existsSync(resolve(process.cwd(), 'public/fonts/pingfang-sc-regular.ttf'))).toBe(true);
|
||||
expect(existsSync(resolve(process.cwd(), 'public/fonts/pingfang-sc-thin.ttf'))).toBe(true);
|
||||
expect(existsSync(resolve(process.cwd(), 'public/fonts/pingfang-sc-medium.ttf'))).toBe(true);
|
||||
expect(existsSync(resolve(process.cwd(), 'public/fonts/pingfang-sc-semibold.ttf'))).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the theme color for trust statistics', () => {
|
||||
const source = readComponent('TrustSection');
|
||||
expect(source).toContain('trust-stat-value text-primary');
|
||||
expect(source).toContain('trust-illustration');
|
||||
expect(source).toContain('margin-bottom: -40px');
|
||||
});
|
||||
|
||||
it('temporarily hides recommendation and solution navigation', () => {
|
||||
const source = readComponent('AppHeader');
|
||||
expect(source).toContain('v-if="SHOW_EXTENDED_NAV"');
|
||||
});
|
||||
|
||||
it('uses a compact desktop navigation font size', () => {
|
||||
const source = readComponent('AppHeader');
|
||||
expect(source).toContain('desktop-nav');
|
||||
expect(source).toContain('.desktop-nav { font-size: 18px; }');
|
||||
});
|
||||
|
||||
it('aligns country flags and keeps the more-products action at the bottom', () => {
|
||||
const source = readComponent('PodProducts');
|
||||
expect(source).toContain('country-button');
|
||||
expect(source).toContain('country-flag');
|
||||
expect(source).toContain('more-products');
|
||||
expect(source).toContain('more-products-link text-primary');
|
||||
expect(source).not.toContain('fa-arrow-right');
|
||||
expect(source).toContain('margin-top: auto');
|
||||
});
|
||||
});
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import FeatureCards from '../app/components/FeatureCards.vue';
|
||||
import StepProcess from '../app/components/StepProcess.vue';
|
||||
|
||||
const readComponent = (name: string) =>
|
||||
readFileSync(resolve(process.cwd(), `app/components/${name}.vue`), 'utf8');
|
||||
|
||||
describe('homepage navigation and carousel behavior', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('uses current-page login navigation for every homepage CTA', () => {
|
||||
const components = ['AppHeader', 'HeroBanner', 'StepProcess', 'CtaBanner'];
|
||||
|
||||
for (const component of components) {
|
||||
const source = readComponent(component);
|
||||
expect(source).not.toMatch(/target="_blank"[^>]*>\s*(登录|立即开始|免费试用|立即免费使用)/u);
|
||||
expect(source).toContain("window.location.href = 'https://inkpod.vip/user/login'");
|
||||
}
|
||||
});
|
||||
|
||||
it('advances the four-step content automatically', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('ref', ref);
|
||||
const wrapper = mount(StepProcess);
|
||||
|
||||
expect(wrapper.get('[data-testid="step-image-0"]').classes()).toContain('opacity-100');
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(wrapper.get('[data-testid="step-image-1"]').classes()).toContain('opacity-100');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('fills the active step progress line before advancing', () => {
|
||||
const source = readComponent('StepProcess');
|
||||
expect(source).toContain('step-progress');
|
||||
expect(source).toContain('animation: fill-step-progress 2s linear forwards');
|
||||
expect(source).toContain('@keyframes fill-step-progress');
|
||||
expect(source).toContain('to { width: 100%; }');
|
||||
expect(source).toContain('class="step-title text-xl lg:text-4xl"');
|
||||
expect(source).not.toContain('step-title text-xl font-bold');
|
||||
});
|
||||
|
||||
it('advances the feature content every two seconds', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('ref', ref);
|
||||
const wrapper = mount(FeatureCards);
|
||||
|
||||
expect(wrapper.get('[data-testid="feature-image-0"]').classes()).toContain('opacity-100');
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(wrapper.get('[data-testid="feature-image-1"]').classes()).toContain('opacity-100');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('keeps feature items aligned with the image and uses restrained titles', () => {
|
||||
const source = readComponent('FeatureCards');
|
||||
expect(source).toContain('feature-list');
|
||||
expect(source).toContain('feature-item-title text-xl lg:text-3xl');
|
||||
expect(source).toContain('grid-template-rows: repeat(4, minmax(0, 1fr))');
|
||||
});
|
||||
|
||||
it('runs the customer-case track as an automatic marquee', () => {
|
||||
const source = readComponent('CustomerCases');
|
||||
expect(source).toContain('animation: case-marquee 30s');
|
||||
expect(source).toContain('@media (prefers-reduced-motion: reduce)');
|
||||
});
|
||||
|
||||
it('keeps the hero visual as a capped image instead of a background', () => {
|
||||
const source = readComponent('HeroBanner');
|
||||
expect(source).toMatch(/<img[^>]+src="\/主视觉\.png"/u);
|
||||
expect(source).not.toMatch(/background(?:-image)?:\s*[^;]*主视觉\.png/u);
|
||||
expect(source).toContain('hero-visual');
|
||||
});
|
||||
|
||||
it('styles the hero emphasis and separates the three selling points', () => {
|
||||
const source = readComponent('HeroBanner');
|
||||
expect(source).toContain('hero-copy');
|
||||
expect(source).toContain('hero-gradient-text');
|
||||
expect(source).toContain('linear-gradient(90deg, #111827 0%, #111827 28%, #ff6800 100%)');
|
||||
expect(source).toContain('.hero-copy { align-items: flex-start; }');
|
||||
expect(source).toContain('hero-subtitle font-thin');
|
||||
expect(source).toContain('selling-point-separator');
|
||||
expect(source).toContain('aria-hidden="true">|</span>');
|
||||
});
|
||||
|
||||
it('loads the supplied PingFang SC font weights globally', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'app/assets/css/tailwind.css'), 'utf8');
|
||||
expect(css).toContain("font-family: 'PingFang SC'");
|
||||
expect(css).toContain("url('/fonts/pingfang-sc-regular.ttf')");
|
||||
expect(css).toContain("url('/fonts/pingfang-sc-thin.ttf')");
|
||||
expect(css).toContain("url('/fonts/pingfang-sc-medium.ttf')");
|
||||
expect(css).toContain("url('/fonts/pingfang-sc-semibold.ttf')");
|
||||
expect(existsSync(resolve(process.cwd(), 'public/fonts/pingfang-sc-regular.ttf'))).toBe(true);
|
||||
expect(existsSync(resolve(process.cwd(), 'public/fonts/pingfang-sc-thin.ttf'))).toBe(true);
|
||||
expect(existsSync(resolve(process.cwd(), 'public/fonts/pingfang-sc-medium.ttf'))).toBe(true);
|
||||
expect(existsSync(resolve(process.cwd(), 'public/fonts/pingfang-sc-semibold.ttf'))).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the theme color for trust statistics', () => {
|
||||
const source = readComponent('TrustSection');
|
||||
expect(source).toContain('trust-stat-value text-primary');
|
||||
expect(source).toContain('trust-illustration');
|
||||
expect(source).toContain('margin-bottom: -40px');
|
||||
});
|
||||
|
||||
it('temporarily hides recommendation and solution navigation', () => {
|
||||
const source = readComponent('AppHeader');
|
||||
expect(source).toContain('v-if="SHOW_EXTENDED_NAV"');
|
||||
});
|
||||
|
||||
it('uses a compact desktop navigation font size', () => {
|
||||
const source = readComponent('AppHeader');
|
||||
expect(source).toContain('desktop-nav');
|
||||
expect(source).toContain('.desktop-nav { font-size: 18px; }');
|
||||
});
|
||||
|
||||
it('aligns country flags and keeps the more-products action at the bottom', () => {
|
||||
const source = readComponent('PodProducts');
|
||||
expect(source).toContain('country-button');
|
||||
expect(source).toContain('country-flag');
|
||||
expect(source).toContain('more-products');
|
||||
expect(source).toContain('more-products-link text-primary');
|
||||
expect(source).not.toContain('fa-arrow-right');
|
||||
expect(source).toContain('margin-top: auto');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import ProductCard from '../app/components/product/ProductCard.vue';
|
||||
import type { Product } from '../app/composables/useProductCenter';
|
||||
|
||||
describe('ProductCard', () => {
|
||||
it('links the entire product card to its Inkpod detail page', () => {
|
||||
const product: Product = {
|
||||
id: '12345', name: '测试商品', priority: 0, image: null, price: '28',
|
||||
country: { id: '1', name: '美国', icon: null },
|
||||
category: { id: '2', name: 'T恤', icon: null, parentId: null, children: [] },
|
||||
tag: null, tags: [],
|
||||
};
|
||||
const wrapper = mount(ProductCard, { props: { product } });
|
||||
const link = wrapper.get('a.product-card');
|
||||
expect(link.attributes('href')).toBe('https://inkpod.vip/portal/detail/12345');
|
||||
expect(link.attributes('target')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import ProductCard from '../app/components/product/ProductCard.vue';
|
||||
import type { Product } from '../app/composables/useProductCenter';
|
||||
|
||||
describe('ProductCard', () => {
|
||||
it('links the entire product card to its Inkpod detail page', () => {
|
||||
const product: Product = {
|
||||
id: '12345', name: '测试商品', priority: 0, image: null, price: '28',
|
||||
country: { id: '1', name: '美国', icon: null },
|
||||
category: { id: '2', name: 'T恤', icon: null, parentId: null, children: [] },
|
||||
tag: null, tags: [],
|
||||
};
|
||||
const wrapper = mount(ProductCard, { props: { product } });
|
||||
const link = wrapper.get('a.product-card');
|
||||
expect(link.attributes('href')).toBe('https://inkpod.vip/portal/detail/12345');
|
||||
expect(link.attributes('target')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('product center layout', () => {
|
||||
it('removes the sidebar left edge and extends filter dividers to the viewport right', () => {
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), 'app/pages/product-center.vue'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect(source).not.toMatch(/\.sidebar-shell\s*\{[^}]*border-left:/u);
|
||||
expect(source).toContain('.countries::after, .filter-tools::after');
|
||||
expect(source).toContain('right: calc(-1 * var(--page-gutter))');
|
||||
expect(source).toContain('left: 0');
|
||||
expect(source).toContain('height: 1px');
|
||||
expect(source).toContain('background: #e5e7eb');
|
||||
});
|
||||
|
||||
it('adds breathing room between root category rows', () => {
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), 'app/components/product/ProductSidebar.vue'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect(source).toMatch(/\.root-row\s*\{[^}]*margin-bottom:\s*10px/u);
|
||||
});
|
||||
});
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('product center layout', () => {
|
||||
it('removes the sidebar left edge and extends filter dividers to the viewport right', () => {
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), 'app/pages/product-center.vue'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect(source).not.toMatch(/\.sidebar-shell\s*\{[^}]*border-left:/u);
|
||||
expect(source).toContain('.countries::after, .filter-tools::after');
|
||||
expect(source).toContain('right: calc(-1 * var(--page-gutter))');
|
||||
expect(source).toContain('left: 0');
|
||||
expect(source).toContain('height: 1px');
|
||||
expect(source).toContain('background: #e5e7eb');
|
||||
});
|
||||
|
||||
it('adds breathing room between root category rows', () => {
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), 'app/components/product/ProductSidebar.vue'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect(source).toMatch(/\.root-row\s*\{[^}]*margin-bottom:\s*10px/u);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import ProductFilterBar from '../app/components/product/ProductFilterBar.vue';
|
||||
|
||||
describe('ProductFilterBar', () => {
|
||||
it('keeps search visible while showing a separate clear button for text', async () => {
|
||||
const wrapper = mount(ProductFilterBar, {
|
||||
props: { keyword: '' },
|
||||
});
|
||||
|
||||
await wrapper.get('input').setValue('卫衣');
|
||||
|
||||
expect(wrapper.get('button[type="submit"]').text()).toBe('搜索');
|
||||
expect(wrapper.get('.clear-search').attributes('aria-label')).toBe('清除搜索');
|
||||
});
|
||||
});
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import ProductFilterBar from '../app/components/product/ProductFilterBar.vue';
|
||||
|
||||
describe('ProductFilterBar', () => {
|
||||
it('keeps search visible while showing a separate clear button for text', async () => {
|
||||
const wrapper = mount(ProductFilterBar, {
|
||||
props: { keyword: '' },
|
||||
});
|
||||
|
||||
await wrapper.get('input').setValue('卫衣');
|
||||
|
||||
expect(wrapper.get('button[type="submit"]').text()).toBe('搜索');
|
||||
expect(wrapper.get('.clear-search').attributes('aria-label')).toBe('清除搜索');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import ProductSidebar from '../app/components/product/ProductSidebar.vue';
|
||||
import type { Category } from '../app/composables/useProductCenter';
|
||||
|
||||
const categories: Category[] = [{
|
||||
id: 'men',
|
||||
name: '男士服装',
|
||||
icon: null,
|
||||
parentId: null,
|
||||
children: [{ id: 'men-tee', name: 'T恤', icon: null, parentId: 'men', children: [] }],
|
||||
}];
|
||||
|
||||
describe('ProductSidebar', () => {
|
||||
it('selects and expands a parent category when it is clicked', async () => {
|
||||
const wrapper = mount(ProductSidebar, {
|
||||
props: { categories, activeCategoryId: null },
|
||||
});
|
||||
|
||||
await wrapper.findAll('.root-row')[1]?.trigger('click');
|
||||
|
||||
expect(wrapper.emitted('update:activeCategoryId')).toEqual([['men']]);
|
||||
expect(wrapper.text()).toContain('T恤');
|
||||
});
|
||||
});
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import ProductSidebar from '../app/components/product/ProductSidebar.vue';
|
||||
import type { Category } from '../app/composables/useProductCenter';
|
||||
|
||||
const categories: Category[] = [{
|
||||
id: 'men',
|
||||
name: '男士服装',
|
||||
icon: null,
|
||||
parentId: null,
|
||||
children: [{ id: 'men-tee', name: 'T恤', icon: null, parentId: 'men', children: [] }],
|
||||
}];
|
||||
|
||||
describe('ProductSidebar', () => {
|
||||
it('selects and expands a parent category when it is clicked', async () => {
|
||||
const wrapper = mount(ProductSidebar, {
|
||||
props: { categories, activeCategoryId: null },
|
||||
});
|
||||
|
||||
await wrapper.findAll('.root-row')[1]?.trigger('click');
|
||||
|
||||
expect(wrapper.emitted('update:activeCategoryId')).toEqual([['men']]);
|
||||
expect(wrapper.text()).toContain('T恤');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,100 +1,100 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import ProductTagFilter from '../app/components/product/ProductTagFilter.vue';
|
||||
import type { Tag, TagGroup } from '../app/composables/useProductCenter';
|
||||
|
||||
const tagGroups: TagGroup[] = [
|
||||
{ id: 'shipping', name: '物流渠道', icon: null, color: null, sortOrder: 1 },
|
||||
{ id: 'position', name: '印刷位置', icon: null, color: null, sortOrder: 2 },
|
||||
{ id: 'craft', name: '印刷工艺', icon: null, color: null, sortOrder: 3 },
|
||||
];
|
||||
|
||||
const tags: Tag[] = [
|
||||
{
|
||||
id: 'free-shipping',
|
||||
name: '包邮',
|
||||
color: null,
|
||||
fontColor: null,
|
||||
group: { id: 'shipping', name: '物流渠道', sortOrder: 1 },
|
||||
},
|
||||
{
|
||||
id: 'heat-transfer',
|
||||
name: '烫画',
|
||||
color: null,
|
||||
fontColor: null,
|
||||
group: { id: 'craft', name: '印刷工艺', sortOrder: 3 },
|
||||
},
|
||||
];
|
||||
|
||||
describe('ProductTagFilter', () => {
|
||||
it('renders selected logistics and craft tags and removes them through the existing event', async () => {
|
||||
const wrapper = mount(ProductTagFilter, {
|
||||
props: {
|
||||
tags,
|
||||
tagGroups,
|
||||
selectedTagIds: ['free-shipping', 'heat-transfer'],
|
||||
},
|
||||
});
|
||||
|
||||
const selectedTags = wrapper.findAll('.selected-tag');
|
||||
expect(selectedTags.map((tag) => tag.text())).toEqual(['包邮', '烫画']);
|
||||
|
||||
await selectedTags[0]?.find('.selected-tag-remove').trigger('click');
|
||||
expect(wrapper.emitted('toggle-tag')).toEqual([['free-shipping']]);
|
||||
});
|
||||
|
||||
it('places a craft-only selection at the left edge of the selected tag row', () => {
|
||||
const wrapper = mount(ProductTagFilter, {
|
||||
props: { tags, tagGroups, selectedTagIds: ['heat-transfer'] },
|
||||
});
|
||||
|
||||
const selectedRow = wrapper.get('.selected-tags');
|
||||
expect(selectedRow.findAll('.selected-tag')).toHaveLength(1);
|
||||
expect(selectedRow.get('.selected-tag').text()).toBe('烫画');
|
||||
expect(selectedRow.classes()).toContain('craft-only');
|
||||
});
|
||||
|
||||
it('closes the dropdown after selection and when clicking outside', async () => {
|
||||
const wrapper = mount(ProductTagFilter, {
|
||||
attachTo: document.body,
|
||||
props: { tags, tagGroups, selectedTagIds: [] },
|
||||
});
|
||||
|
||||
await wrapper.findAll('.filter-button')[0]?.trigger('click');
|
||||
expect(wrapper.find('.menu').exists()).toBe(true);
|
||||
await wrapper.get('.menu button').trigger('click');
|
||||
expect(wrapper.find('.menu').exists()).toBe(false);
|
||||
|
||||
await wrapper.findAll('.filter-button')[0]?.trigger('click');
|
||||
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('.menu').exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('uses backend group membership and excludes the print-position group', async () => {
|
||||
const craftTags: Tag[] = ['烫画', '直喷', '不打印'].map((name, index) => ({
|
||||
id: `craft-${index}`,
|
||||
name,
|
||||
color: null,
|
||||
fontColor: null,
|
||||
group: { id: 'craft', name: '印刷工艺', sortOrder: 3 },
|
||||
}));
|
||||
const positionTag: Tag = {
|
||||
id: 'single-side', name: '单面印', color: null, fontColor: null,
|
||||
group: { id: 'position', name: '印刷位置', sortOrder: 2 },
|
||||
};
|
||||
const wrapper = mount(ProductTagFilter, {
|
||||
props: { tags: [tags[0]!, ...craftTags, positionTag], tagGroups, selectedTagIds: [] },
|
||||
});
|
||||
|
||||
expect(wrapper.findAll('.filter-button').map((item) => item.text())).toEqual(['物流', '工艺']);
|
||||
await wrapper.findAll('.filter-button')[1]?.trigger('click');
|
||||
|
||||
expect(wrapper.findAll('.menu button').map((item) => item.text())).toEqual([
|
||||
'烫画',
|
||||
'直喷',
|
||||
'不打印',
|
||||
]);
|
||||
});
|
||||
});
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import ProductTagFilter from '../app/components/product/ProductTagFilter.vue';
|
||||
import type { Tag, TagGroup } from '../app/composables/useProductCenter';
|
||||
|
||||
const tagGroups: TagGroup[] = [
|
||||
{ id: 'shipping', name: '物流渠道', icon: null, color: null, sortOrder: 1 },
|
||||
{ id: 'position', name: '印刷位置', icon: null, color: null, sortOrder: 2 },
|
||||
{ id: 'craft', name: '印刷工艺', icon: null, color: null, sortOrder: 3 },
|
||||
];
|
||||
|
||||
const tags: Tag[] = [
|
||||
{
|
||||
id: 'free-shipping',
|
||||
name: '包邮',
|
||||
color: null,
|
||||
fontColor: null,
|
||||
group: { id: 'shipping', name: '物流渠道', sortOrder: 1 },
|
||||
},
|
||||
{
|
||||
id: 'heat-transfer',
|
||||
name: '烫画',
|
||||
color: null,
|
||||
fontColor: null,
|
||||
group: { id: 'craft', name: '印刷工艺', sortOrder: 3 },
|
||||
},
|
||||
];
|
||||
|
||||
describe('ProductTagFilter', () => {
|
||||
it('renders selected logistics and craft tags and removes them through the existing event', async () => {
|
||||
const wrapper = mount(ProductTagFilter, {
|
||||
props: {
|
||||
tags,
|
||||
tagGroups,
|
||||
selectedTagIds: ['free-shipping', 'heat-transfer'],
|
||||
},
|
||||
});
|
||||
|
||||
const selectedTags = wrapper.findAll('.selected-tag');
|
||||
expect(selectedTags.map((tag) => tag.text())).toEqual(['包邮', '烫画']);
|
||||
|
||||
await selectedTags[0]?.find('.selected-tag-remove').trigger('click');
|
||||
expect(wrapper.emitted('toggle-tag')).toEqual([['free-shipping']]);
|
||||
});
|
||||
|
||||
it('places a craft-only selection at the left edge of the selected tag row', () => {
|
||||
const wrapper = mount(ProductTagFilter, {
|
||||
props: { tags, tagGroups, selectedTagIds: ['heat-transfer'] },
|
||||
});
|
||||
|
||||
const selectedRow = wrapper.get('.selected-tags');
|
||||
expect(selectedRow.findAll('.selected-tag')).toHaveLength(1);
|
||||
expect(selectedRow.get('.selected-tag').text()).toBe('烫画');
|
||||
expect(selectedRow.classes()).toContain('craft-only');
|
||||
});
|
||||
|
||||
it('closes the dropdown after selection and when clicking outside', async () => {
|
||||
const wrapper = mount(ProductTagFilter, {
|
||||
attachTo: document.body,
|
||||
props: { tags, tagGroups, selectedTagIds: [] },
|
||||
});
|
||||
|
||||
await wrapper.findAll('.filter-button')[0]?.trigger('click');
|
||||
expect(wrapper.find('.menu').exists()).toBe(true);
|
||||
await wrapper.get('.menu button').trigger('click');
|
||||
expect(wrapper.find('.menu').exists()).toBe(false);
|
||||
|
||||
await wrapper.findAll('.filter-button')[0]?.trigger('click');
|
||||
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('.menu').exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('uses backend group membership and excludes the print-position group', async () => {
|
||||
const craftTags: Tag[] = ['烫画', '直喷', '不打印'].map((name, index) => ({
|
||||
id: `craft-${index}`,
|
||||
name,
|
||||
color: null,
|
||||
fontColor: null,
|
||||
group: { id: 'craft', name: '印刷工艺', sortOrder: 3 },
|
||||
}));
|
||||
const positionTag: Tag = {
|
||||
id: 'single-side', name: '单面印', color: null, fontColor: null,
|
||||
group: { id: 'position', name: '印刷位置', sortOrder: 2 },
|
||||
};
|
||||
const wrapper = mount(ProductTagFilter, {
|
||||
props: { tags: [tags[0]!, ...craftTags, positionTag], tagGroups, selectedTagIds: [] },
|
||||
});
|
||||
|
||||
expect(wrapper.findAll('.filter-button').map((item) => item.text())).toEqual(['物流', '工艺']);
|
||||
await wrapper.findAll('.filter-button')[1]?.trigger('click');
|
||||
|
||||
expect(wrapper.findAll('.menu button').map((item) => item.text())).toEqual([
|
||||
'烫画',
|
||||
'直喷',
|
||||
'不打印',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { config } from '@vue/test-utils';
|
||||
|
||||
config.global.stubs = {
|
||||
NuxtLink: { template: '<a><slot /></a>' },
|
||||
};
|
||||
import { config } from '@vue/test-utils';
|
||||
|
||||
config.global.stubs = {
|
||||
NuxtLink: { template: '<a><slot /></a>' },
|
||||
};
|
||||
|
||||
@@ -1,103 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Category, Tag } from '../app/composables/useProductCenter';
|
||||
import { findCategoryIdByName, findCategoryIdForPodName, resolveBackendAssetUrl, resolveCategoryIdFromQuery, resolveCountryIdFromQuery, resolveDefaultProductFilters, selectExclusiveTagIds } from '../app/composables/useProductCenter';
|
||||
|
||||
describe('resolveCountryIdFromQuery', () => {
|
||||
const countries = [{ id: '45', name: '美国', icon: null }];
|
||||
|
||||
it('accepts only a configured backend country id', () => {
|
||||
expect(resolveCountryIdFromQuery('45', countries)).toBe('45');
|
||||
expect(resolveCountryIdFromQuery('999', countries)).toBeUndefined();
|
||||
expect(resolveCountryIdFromQuery(['45'], countries)).toBe('45');
|
||||
});
|
||||
});
|
||||
|
||||
describe('product-center category routing', () => {
|
||||
it('finds and validates nested backend category ids', () => {
|
||||
expect(findCategoryIdByName('T恤', categories)).toBe('tee');
|
||||
expect(resolveCategoryIdFromQuery('tee', categories)).toBe('tee');
|
||||
expect(resolveCategoryIdFromQuery('unknown', categories)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps compound homepage POD names into the backend category tree', () => {
|
||||
const backendCategories: Category[] = [{
|
||||
id: 'women', name: '女士服装', icon: null, parentId: null,
|
||||
children: [{ id: 'tee', name: 'T恤', icon: null, parentId: 'women', children: [] }],
|
||||
}];
|
||||
expect(findCategoryIdForPodName('女士T恤', backendCategories)).toBe('tee');
|
||||
expect(findCategoryIdForPodName('女士背心', backendCategories)).toBe('women');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveBackendAssetUrl', () => {
|
||||
it('resolves backend-relative uploaded and static assets', () => {
|
||||
expect(resolveBackendAssetUrl('/assets/product-center/countries/us.png', 'http://localhost:3001'))
|
||||
.toBe('http://localhost:3001/assets/product-center/countries/us.png');
|
||||
});
|
||||
|
||||
it('preserves absolute, data, and empty asset values', () => {
|
||||
expect(resolveBackendAssetUrl('https://cdn.example/icon.svg', 'http://localhost:3001')).toBe('https://cdn.example/icon.svg');
|
||||
expect(resolveBackendAssetUrl('data:image/svg+xml;base64,AA==', 'http://localhost:3001')).toBe('data:image/svg+xml;base64,AA==');
|
||||
expect(resolveBackendAssetUrl(null, 'http://localhost:3001')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const categories: Category[] = [
|
||||
{
|
||||
id: 'women',
|
||||
name: '女式服装',
|
||||
icon: null,
|
||||
parentId: null,
|
||||
children: [
|
||||
{ id: 'tee', name: 'T恤', icon: null, parentId: 'women', children: [] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const productionCategories: Category[] = [
|
||||
{
|
||||
...categories[0],
|
||||
name: '女士服装',
|
||||
},
|
||||
];
|
||||
|
||||
const tags: Tag[] = [
|
||||
{ id: 'shipping', name: '包邮', color: null, fontColor: null, group: null },
|
||||
{ id: 'transfer', name: '烫画', color: null, fontColor: null, group: null },
|
||||
];
|
||||
|
||||
describe('resolveDefaultProductFilters', () => {
|
||||
it('defaults to all products with no tag filters', () => {
|
||||
expect(resolveDefaultProductFilters(categories, tags)).toEqual({
|
||||
categoryId: undefined,
|
||||
tagIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('omits filters that are unavailable from the backend', () => {
|
||||
expect(resolveDefaultProductFilters([], [])).toEqual({
|
||||
categoryId: undefined,
|
||||
tagIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not infer a category from production category wording', () => {
|
||||
expect(resolveDefaultProductFilters(productionCategories, tags).categoryId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectExclusiveTagIds', () => {
|
||||
const groupedTags: Tag[] = [
|
||||
{ id: 'free-shipping', name: '包邮', color: null, fontColor: null, group: { id: 'shipping', name: '物流', sortOrder: 1 } },
|
||||
{ id: 'paid-shipping', name: '不包邮', color: null, fontColor: null, group: { id: 'shipping', name: '物流', sortOrder: 1 } },
|
||||
{ id: 'transfer', name: '烫画', color: null, fontColor: null, group: { id: 'craft', name: '工艺', sortOrder: 2 } },
|
||||
];
|
||||
|
||||
it('replaces the selected tag within the same group', () => {
|
||||
expect(selectExclusiveTagIds(['free-shipping', 'transfer'], groupedTags, 'paid-shipping')).toEqual(['transfer', 'paid-shipping']);
|
||||
});
|
||||
|
||||
it('keeps selections from other groups and toggles the active item off', () => {
|
||||
expect(selectExclusiveTagIds(['free-shipping', 'transfer'], groupedTags, 'free-shipping')).toEqual(['transfer']);
|
||||
});
|
||||
});
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Category, Tag } from '../app/composables/useProductCenter';
|
||||
import { findCategoryIdByName, findCategoryIdForPodName, resolveBackendAssetUrl, resolveCategoryIdFromQuery, resolveCountryIdFromQuery, resolveDefaultProductFilters, selectExclusiveTagIds } from '../app/composables/useProductCenter';
|
||||
|
||||
describe('resolveCountryIdFromQuery', () => {
|
||||
const countries = [{ id: '45', name: '美国', icon: null }];
|
||||
|
||||
it('accepts only a configured backend country id', () => {
|
||||
expect(resolveCountryIdFromQuery('45', countries)).toBe('45');
|
||||
expect(resolveCountryIdFromQuery('999', countries)).toBeUndefined();
|
||||
expect(resolveCountryIdFromQuery(['45'], countries)).toBe('45');
|
||||
});
|
||||
});
|
||||
|
||||
describe('product-center category routing', () => {
|
||||
it('finds and validates nested backend category ids', () => {
|
||||
expect(findCategoryIdByName('T恤', categories)).toBe('tee');
|
||||
expect(resolveCategoryIdFromQuery('tee', categories)).toBe('tee');
|
||||
expect(resolveCategoryIdFromQuery('unknown', categories)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps compound homepage POD names into the backend category tree', () => {
|
||||
const backendCategories: Category[] = [{
|
||||
id: 'women', name: '女士服装', icon: null, parentId: null,
|
||||
children: [{ id: 'tee', name: 'T恤', icon: null, parentId: 'women', children: [] }],
|
||||
}];
|
||||
expect(findCategoryIdForPodName('女士T恤', backendCategories)).toBe('tee');
|
||||
expect(findCategoryIdForPodName('女士背心', backendCategories)).toBe('women');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveBackendAssetUrl', () => {
|
||||
it('resolves backend-relative uploaded and static assets', () => {
|
||||
expect(resolveBackendAssetUrl('/assets/product-center/countries/us.png', 'http://localhost:3001'))
|
||||
.toBe('http://localhost:3001/assets/product-center/countries/us.png');
|
||||
});
|
||||
|
||||
it('preserves absolute, data, and empty asset values', () => {
|
||||
expect(resolveBackendAssetUrl('https://cdn.example/icon.svg', 'http://localhost:3001')).toBe('https://cdn.example/icon.svg');
|
||||
expect(resolveBackendAssetUrl('data:image/svg+xml;base64,AA==', 'http://localhost:3001')).toBe('data:image/svg+xml;base64,AA==');
|
||||
expect(resolveBackendAssetUrl(null, 'http://localhost:3001')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const categories: Category[] = [
|
||||
{
|
||||
id: 'women',
|
||||
name: '女式服装',
|
||||
icon: null,
|
||||
parentId: null,
|
||||
children: [
|
||||
{ id: 'tee', name: 'T恤', icon: null, parentId: 'women', children: [] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const productionCategories: Category[] = [
|
||||
{
|
||||
...categories[0],
|
||||
name: '女士服装',
|
||||
},
|
||||
];
|
||||
|
||||
const tags: Tag[] = [
|
||||
{ id: 'shipping', name: '包邮', color: null, fontColor: null, group: null },
|
||||
{ id: 'transfer', name: '烫画', color: null, fontColor: null, group: null },
|
||||
];
|
||||
|
||||
describe('resolveDefaultProductFilters', () => {
|
||||
it('defaults to all products with no tag filters', () => {
|
||||
expect(resolveDefaultProductFilters(categories, tags)).toEqual({
|
||||
categoryId: undefined,
|
||||
tagIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('omits filters that are unavailable from the backend', () => {
|
||||
expect(resolveDefaultProductFilters([], [])).toEqual({
|
||||
categoryId: undefined,
|
||||
tagIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not infer a category from production category wording', () => {
|
||||
expect(resolveDefaultProductFilters(productionCategories, tags).categoryId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectExclusiveTagIds', () => {
|
||||
const groupedTags: Tag[] = [
|
||||
{ id: 'free-shipping', name: '包邮', color: null, fontColor: null, group: { id: 'shipping', name: '物流', sortOrder: 1 } },
|
||||
{ id: 'paid-shipping', name: '不包邮', color: null, fontColor: null, group: { id: 'shipping', name: '物流', sortOrder: 1 } },
|
||||
{ id: 'transfer', name: '烫画', color: null, fontColor: null, group: { id: 'craft', name: '工艺', sortOrder: 2 } },
|
||||
];
|
||||
|
||||
it('replaces the selected tag within the same group', () => {
|
||||
expect(selectExclusiveTagIds(['free-shipping', 'transfer'], groupedTags, 'paid-shipping')).toEqual(['transfer', 'paid-shipping']);
|
||||
});
|
||||
|
||||
it('keeps selections from other groups and toggles the active item off', () => {
|
||||
expect(selectExclusiveTagIds(['free-shipping', 'transfer'], groupedTags, 'free-shipping')).toEqual(['transfer']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user