@jjlmoya/utils-textiles 1.24.0 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/category/index.ts +2 -0
- package/src/entries.ts +2 -0
- package/src/index.ts +1 -0
- package/src/tests/legacy_logic_reference.test.ts +65 -0
- package/src/tests/qa-test-helpers.ts +32 -0
- package/src/tests/qa_bibliography_links.test.ts +49 -0
- package/src/tests/qa_claim_evidence.test.ts +69 -0
- package/src/tests/qa_logic_reference_coverage.test.ts +46 -0
- package/src/tests/qa_runtime_i18n.test.ts +99 -0
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/burnTest/component.astro +2 -2
- package/src/tool/embroideryStitchPricingEstimator/component.astro +2 -2
- package/src/tool/embroideryStitchPricingEstimator/dom-views.ts +12 -5
- package/src/tool/fabricProjectCalculator/component.astro +7 -5
- package/src/tool/fabricTruth/component.astro +2 -2
- package/src/tool/knittingGauge/component.astro +1 -2
- package/src/tool/laundryGuide/bibliography.ts +1 -1
- package/src/tool/quiltBindingCalculator/bibliography.astro +6 -0
- package/src/tool/quiltBindingCalculator/bibliography.ts +12 -0
- package/src/tool/quiltBindingCalculator/component.astro +110 -0
- package/src/tool/quiltBindingCalculator/controller.ts +257 -0
- package/src/tool/quiltBindingCalculator/dom-views.ts +56 -0
- package/src/tool/quiltBindingCalculator/entry.ts +29 -0
- package/src/tool/quiltBindingCalculator/evaluator.ts +13 -0
- package/src/tool/quiltBindingCalculator/i18n/de.ts +3 -0
- package/src/tool/quiltBindingCalculator/i18n/en.ts +200 -0
- package/src/tool/quiltBindingCalculator/i18n/es.ts +3 -0
- package/src/tool/quiltBindingCalculator/i18n/fr.ts +3 -0
- package/src/tool/quiltBindingCalculator/i18n/id.ts +3 -0
- package/src/tool/quiltBindingCalculator/i18n/it.ts +3 -0
- package/src/tool/quiltBindingCalculator/i18n/ja.ts +3 -0
- package/src/tool/quiltBindingCalculator/i18n/ko.ts +3 -0
- package/src/tool/quiltBindingCalculator/i18n/nl.ts +3 -0
- package/src/tool/quiltBindingCalculator/i18n/pl.ts +3 -0
- package/src/tool/quiltBindingCalculator/i18n/pt.ts +3 -0
- package/src/tool/quiltBindingCalculator/i18n/ru.ts +3 -0
- package/src/tool/quiltBindingCalculator/i18n/shared.ts +514 -0
- package/src/tool/quiltBindingCalculator/i18n/sv.ts +3 -0
- package/src/tool/quiltBindingCalculator/i18n/tr.ts +3 -0
- package/src/tool/quiltBindingCalculator/i18n/zh.ts +3 -0
- package/src/tool/quiltBindingCalculator/index.ts +11 -0
- package/src/tool/quiltBindingCalculator/logic.test.ts +70 -0
- package/src/tool/quiltBindingCalculator/logic.ts +93 -0
- package/src/tool/quiltBindingCalculator/quilt-binding-length-and-strip-calculator.css +474 -0
- package/src/tool/quiltBindingCalculator/seo.astro +15 -0
- package/src/tool/quiltBindingCalculator/storage.ts +26 -0
- package/src/tool/quiltBindingCalculator/ui.ts +51 -0
- package/src/tool/stainChemistry/bibliography.ts +1 -1
- package/src/tool/stainChemistry/component.astro +4 -3
- package/src/tool/yarnCalculator/component.astro +5 -6
- package/src/tool/yarnCalculator/validation.ts +17 -0
- package/src/tools.ts +2 -2
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { sewingPatternScaler } from '../tool/sewingPatternScaler/entry';
|
|
|
12
12
|
import { needleConverter } from '../tool/needleConverter/entry';
|
|
13
13
|
import { yarnCalculator } from '../tool/yarnCalculator/entry';
|
|
14
14
|
import { embroideryStitchPricingEstimator } from '../tool/embroideryStitchPricingEstimator/entry';
|
|
15
|
+
import { quiltBindingCalculator } from '../tool/quiltBindingCalculator/entry';
|
|
15
16
|
|
|
16
17
|
export const textilesCategory: TextilesCategoryEntry = {
|
|
17
18
|
icon: 'mdi:texture',
|
|
@@ -29,6 +30,7 @@ export const textilesCategory: TextilesCategoryEntry = {
|
|
|
29
30
|
needleConverter,
|
|
30
31
|
yarnCalculator,
|
|
31
32
|
embroideryStitchPricingEstimator,
|
|
33
|
+
quiltBindingCalculator,
|
|
32
34
|
],
|
|
33
35
|
i18n: {
|
|
34
36
|
es: () => import('./i18n/es').then((m) => m.content),
|
package/src/entries.ts
CHANGED
|
@@ -23,6 +23,8 @@ export { yarnCalculator } from './tool/yarnCalculator/entry';
|
|
|
23
23
|
export type { YarnCalculatorLocaleContent } from './tool/yarnCalculator/entry';
|
|
24
24
|
export { embroideryStitchPricingEstimator } from './tool/embroideryStitchPricingEstimator/entry';
|
|
25
25
|
export type { EmbroideryStitchPricingEstimatorLocaleContent } from './tool/embroideryStitchPricingEstimator/entry';
|
|
26
|
+
export { quiltBindingCalculator } from './tool/quiltBindingCalculator/entry';
|
|
27
|
+
export type { QuiltBindingCalculatorLocaleContent } from './tool/quiltBindingCalculator/entry';
|
|
26
28
|
export { textilesCategory } from './category';
|
|
27
29
|
import { burnTest } from './tool/burnTest/entry';
|
|
28
30
|
import { clothingSizeConverter } from './tool/clothingSizeConverter/entry';
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ export { SEWING_PATTERN_SCALER_TOOL } from './tool/sewingPatternScaler';
|
|
|
14
14
|
export { NEEDLE_CONVERTER_TOOL } from './tool/needleConverter';
|
|
15
15
|
export { YARN_CALCULATOR_TOOL } from './tool/yarnCalculator';
|
|
16
16
|
export { EMBROIDERY_STITCH_PRICING_ESTIMATOR_TOOL } from './tool/embroideryStitchPricingEstimator';
|
|
17
|
+
export { QUILT_BINDING_CALCULATOR_TOOL } from './tool/quiltBindingCalculator';
|
|
17
18
|
|
|
18
19
|
export type {
|
|
19
20
|
KnownLocale,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { STEPS } from '../tool/burnTest/logic';
|
|
3
|
+
import { findBestSize } from '../tool/clothingSizeConverter/logic';
|
|
4
|
+
import { computeMeters, getRulerStep, shopAdviceMeters } from '../tool/fabricProjectCalculator/logic';
|
|
5
|
+
import { FabricEngine } from '../tool/fabricTruth/logic';
|
|
6
|
+
import { calculateWOF, computePhAdvice } from '../tool/fiberPrep/logic';
|
|
7
|
+
import { adjustForMultiples, computeNeedleStep, computeScaleFactor, parseMultiples } from '../tool/knittingGauge/logic';
|
|
8
|
+
import type { TextileData } from '../tool/laundryGuide/logic';
|
|
9
|
+
import { DEFAULT_IDX, NEEDLE_DATA } from '../tool/needleConverter/logic';
|
|
10
|
+
import { computeLengthAdj, computeLateralAdj, computeResult, getStandardMeasures, isLargeScale } from '../tool/sewingPatternScaler/logic';
|
|
11
|
+
import type { SizeMapping } from '../tool/shoeSizeConverter/logic';
|
|
12
|
+
import { getFiberType } from '../tool/stainChemistry/logic';
|
|
13
|
+
import { computeBalls, computeTotal, getBaseMeters, getProjectParts } from '../tool/yarnCalculator/logic';
|
|
14
|
+
|
|
15
|
+
describe('legacy calculator logic reference cases', () => {
|
|
16
|
+
it('keeps public APIs covered with representative calculations', () => {
|
|
17
|
+
expect(STEPS).toEqual(['flame', 'odor', 'residue', 'smoke']);
|
|
18
|
+
expect(findBestSize(89, 69, 95, [
|
|
19
|
+
{ size: '38', chest: 88, waist: 68, hip: 94 },
|
|
20
|
+
{ size: '40', chest: 92, waist: 72, hip: 98 },
|
|
21
|
+
]).size).toBe('38');
|
|
22
|
+
expect(computeMeters(1.2, 1, false, 1.5)).toBe(1.35);
|
|
23
|
+
expect(shopAdviceMeters(1.4)).toBe(1.5);
|
|
24
|
+
expect(getRulerStep(2)).toBe(0.5);
|
|
25
|
+
|
|
26
|
+
const fiberData = {
|
|
27
|
+
cotton: { name: 'Cotton', family: 'natural' as const, breathability: 8, durability: 6, warmth: 4 },
|
|
28
|
+
polyester: { name: 'Polyester', family: 'synthetic' as const, breathability: 3, durability: 9, warmth: 5 },
|
|
29
|
+
};
|
|
30
|
+
expect(FabricEngine.calculateVerdict([{ fiberId: 'cotton', percentage: 100 }], fiberData, {
|
|
31
|
+
natural: { label: 'Natural', description: 'Natural fibre' },
|
|
32
|
+
}).label).toBe('Natural');
|
|
33
|
+
expect(FabricEngine.getAverages([{ fiberId: 'cotton', percentage: 100 }], fiberData).b).toBe(8);
|
|
34
|
+
expect(calculateWOF(100, 'protein')).toEqual({ alum: 15, creamOfTartar: 2, iron: 1 });
|
|
35
|
+
expect(computePhAdvice(6.5, 7, 10, {
|
|
36
|
+
optimal: 'ok', raisePrefix: 'raise', raiseSuffix: 'acid', lowerPrefix: 'lower', lowerSuffix: 'base',
|
|
37
|
+
})).toContain('raise');
|
|
38
|
+
expect(parseMultiples('4 + 1')).toEqual({ base: 4, offset: 1 });
|
|
39
|
+
expect(adjustForMultiples(10, 4, 1)).toBe(9);
|
|
40
|
+
expect(computeScaleFactor(20, 22)).toBeCloseTo(-9.09, 2);
|
|
41
|
+
expect(computeNeedleStep(11)).toBe(0.75);
|
|
42
|
+
expect(NEEDLE_DATA[DEFAULT_IDX].mm).toBe(4);
|
|
43
|
+
expect(getStandardMeasures('40').chest).toBe(92);
|
|
44
|
+
expect(computeResult({ chest: 92, waist: 72, hips: 98, length: 60 }, 4)).toEqual({ c: 96, w: 76, h: 102, l: 60 });
|
|
45
|
+
expect(computeLateralAdj(88, 96)).toBe(2);
|
|
46
|
+
expect(computeLengthAdj(59, 61)).toBe(2);
|
|
47
|
+
expect(isLargeScale(84, 100)).toBe(true);
|
|
48
|
+
expect(getFiberType('polyester', { polyester: { name: 'Polyester', family: 'synthetic' } })).toBe('synthetic');
|
|
49
|
+
expect(getBaseMeters('sweater', 'm', 'fingering')).toBe(1250);
|
|
50
|
+
expect(computeTotal(1250, true)).toBe(1719);
|
|
51
|
+
expect(computeBalls(400, 200)).toBe(2);
|
|
52
|
+
expect(getProjectParts('hat')).toEqual({ body: 1, sleeves: 0 });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('keeps data-only logic contracts representable', () => {
|
|
56
|
+
const textile: TextileData = {
|
|
57
|
+
name: 'Cotton', description: 'Plant fibre', family: 'natural', origin: 'Plant', breathability: 8,
|
|
58
|
+
durability: 6, warmth: 4, washing: 'Machine wash', drying: 'Air dry', ironing: 'Medium', donts: [],
|
|
59
|
+
sos: 'Check the label', icon: 'cotton', color: '#fff', maxTemp: '40 C',
|
|
60
|
+
};
|
|
61
|
+
const shoe: SizeMapping = { EU: '38', US: '8', UK: '5', CM: '24' };
|
|
62
|
+
expect(textile.family).toBe('natural');
|
|
63
|
+
expect(shoe.EU).toBe('38');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const repositoryRoot = process.cwd();
|
|
5
|
+
export const toolRoot = join(repositoryRoot, 'src', 'tool');
|
|
6
|
+
|
|
7
|
+
export function listToolDirectories(): string[] {
|
|
8
|
+
return readdirSync(toolRoot)
|
|
9
|
+
.map((name) => join(toolRoot, name))
|
|
10
|
+
.filter((path) => statSync(path).isDirectory());
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function findFiles(directory: string, extensions: string[]): string[] {
|
|
14
|
+
const files: string[] = [];
|
|
15
|
+
|
|
16
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
17
|
+
const path = join(directory, entry.name);
|
|
18
|
+
if (entry.isDirectory()) files.push(...findFiles(path, extensions));
|
|
19
|
+
else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(path);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return files;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function read(path: string): string {
|
|
26
|
+
return readFileSync(path, 'utf8');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function displayPath(path: string): string {
|
|
30
|
+
return relative(repositoryRoot, path).replace(/\\/g, '/');
|
|
31
|
+
}
|
|
32
|
+
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
import type { ToolLocaleContent } from '../types';
|
|
4
|
+
|
|
5
|
+
interface LinkFailure {
|
|
6
|
+
tool: string;
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function inspectUrl(tool: string, entryUrl: string, seen: Set<string>): LinkFailure[] {
|
|
11
|
+
let url: URL;
|
|
12
|
+
try {
|
|
13
|
+
url = new URL(entryUrl);
|
|
14
|
+
} catch {
|
|
15
|
+
return [{ tool, message: `invalid URL: ${entryUrl}` }];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const failures: LinkFailure[] = [];
|
|
19
|
+
if (url.protocol !== 'https:') {
|
|
20
|
+
failures.push({ tool, message: `non-HTTPS URL: ${entryUrl}` });
|
|
21
|
+
}
|
|
22
|
+
if (url.pathname === '/' && !url.search && !url.hash) {
|
|
23
|
+
failures.push({ tool, message: `generic homepage, cite the exact document: ${entryUrl}` });
|
|
24
|
+
}
|
|
25
|
+
if (seen.has(url.href)) {
|
|
26
|
+
failures.push({ tool, message: `duplicate source URL: ${entryUrl}` });
|
|
27
|
+
}
|
|
28
|
+
seen.add(url.href);
|
|
29
|
+
return failures;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function inspectTool(tool: (typeof ALL_TOOLS)[number]): Promise<LinkFailure[]> {
|
|
33
|
+
const loader = tool.entry.i18n.en;
|
|
34
|
+
if (!loader) {
|
|
35
|
+
return [{ tool: tool.entry.id, message: 'English locale loader is missing' }];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const content = (await loader()) as ToolLocaleContent;
|
|
39
|
+
const seen = new Set<string>();
|
|
40
|
+
return (content.bibliography ?? []).flatMap((entry) => inspectUrl(tool.entry.id, entry.url, seen));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('QA: bibliography links are specific and usable', () => {
|
|
44
|
+
it('uses unique HTTPS links to exact source pages instead of generic homepages', async () => {
|
|
45
|
+
const failures = (await Promise.all(ALL_TOOLS.map(inspectTool))).flat();
|
|
46
|
+
const messages = failures.map(({ tool, message }) => `${tool}: ${message}`);
|
|
47
|
+
expect(messages, `Bibliography hygiene failures:\n${messages.join('\n')}`).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { displayPath, listToolDirectories, read } from './qa-test-helpers';
|
|
5
|
+
|
|
6
|
+
const evidenceClaims = [
|
|
7
|
+
/\bofficial algorithm\b/i,
|
|
8
|
+
/\balgoritmo oficial\b/i,
|
|
9
|
+
/\b(?:records|data|rates|tables) updated(?:\s+(?:to|through)\s+20\d{2})?\b/i,
|
|
10
|
+
/\b(?:registros|datos|tasas|tablas) actualizad[oa]s?(?:\s+(?:a|hasta)\s+20\d{2})?\b/i,
|
|
11
|
+
/\b(?:validated|verified) (?:method|algorithm|calculation)\b/i,
|
|
12
|
+
/\b(?:método|algoritmo|cálculo) (?:validado|verificado)\b/i,
|
|
13
|
+
/\b(?:guarantees|ensures)\b/i,
|
|
14
|
+
/\bgarantiza\b/i,
|
|
15
|
+
/\bofficial (?:data|rate|calculation)\b/i,
|
|
16
|
+
/\b(?:datos|tasa|cálculo) oficial(?:es)?\b/i,
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const requiredEvidenceFields = [
|
|
20
|
+
'reviewedAt',
|
|
21
|
+
'methodology',
|
|
22
|
+
'sources',
|
|
23
|
+
'referenceCases',
|
|
24
|
+
'limitations',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
describe('QA: strong factual claims are traceable', () => {
|
|
28
|
+
it('requires machine-readable validation evidence beside tools making strong claims', () => {
|
|
29
|
+
const failures: string[] = [];
|
|
30
|
+
|
|
31
|
+
for (const directory of listToolDirectories()) {
|
|
32
|
+
const localePaths = ['en', 'es']
|
|
33
|
+
.map((locale) => join(directory, 'i18n', `${locale}.ts`))
|
|
34
|
+
.filter(existsSync);
|
|
35
|
+
const claims = localePaths.flatMap((path) => {
|
|
36
|
+
const source = read(path);
|
|
37
|
+
return evidenceClaims
|
|
38
|
+
.filter((pattern) => pattern.test(source))
|
|
39
|
+
.map((pattern) => `${displayPath(path)} matches ${pattern.source}`);
|
|
40
|
+
});
|
|
41
|
+
if (claims.length === 0) continue;
|
|
42
|
+
|
|
43
|
+
const evidencePath = join(directory, 'validation.ts');
|
|
44
|
+
if (!existsSync(evidencePath)) {
|
|
45
|
+
failures.push(`${displayPath(evidencePath)} missing; claims: ${claims.join(' | ')}`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const evidence = read(evidencePath);
|
|
50
|
+
const missingFields = requiredEvidenceFields.filter(
|
|
51
|
+
(field) => !new RegExp(`\\b${field}\\s*:`).test(evidence),
|
|
52
|
+
);
|
|
53
|
+
if (missingFields.length > 0) {
|
|
54
|
+
failures.push(`${displayPath(evidencePath)} missing fields: ${missingFields.join(', ')}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
expect(
|
|
59
|
+
failures,
|
|
60
|
+
[
|
|
61
|
+
'Claims such as official, validated, guaranteed or updated need inspectable evidence.',
|
|
62
|
+
'Add validation.ts with reviewedAt, methodology, sources, referenceCases and limitations,',
|
|
63
|
+
'or weaken/remove the unsupported claim.',
|
|
64
|
+
...failures,
|
|
65
|
+
].join('\n'),
|
|
66
|
+
).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import {
|
|
5
|
+
displayPath,
|
|
6
|
+
findFiles,
|
|
7
|
+
listToolDirectories,
|
|
8
|
+
read,
|
|
9
|
+
repositoryRoot,
|
|
10
|
+
} from './qa-test-helpers';
|
|
11
|
+
|
|
12
|
+
function hasDedicatedLogicTest(toolDirectory: string): boolean {
|
|
13
|
+
const localCandidates = [
|
|
14
|
+
join(toolDirectory, 'logic.test.ts'),
|
|
15
|
+
join(toolDirectory, 'logic.spec.ts'),
|
|
16
|
+
join(toolDirectory, '__tests__', 'logic.test.ts'),
|
|
17
|
+
join(toolDirectory, '__tests__', 'logic.spec.ts'),
|
|
18
|
+
];
|
|
19
|
+
if (localCandidates.some(existsSync)) return true;
|
|
20
|
+
|
|
21
|
+
const toolName = basename(toolDirectory);
|
|
22
|
+
const centralTests = findFiles(join(repositoryRoot, 'src', 'tests'), ['.test.ts', '.spec.ts']);
|
|
23
|
+
return centralTests.some((testPath) => {
|
|
24
|
+
const source = read(testPath).replace(/\\/g, '/');
|
|
25
|
+
return source.includes(`/tool/${toolName}/logic`) || source.includes(`../tool/${toolName}/logic`);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('QA: calculation logic has reference tests', () => {
|
|
30
|
+
it('gives every public calculator logic module its own behavioral test suite', () => {
|
|
31
|
+
const failures = listToolDirectories()
|
|
32
|
+
.filter((directory) => existsSync(join(directory, 'logic.ts')))
|
|
33
|
+
.filter((directory) => !hasDedicatedLogicTest(directory))
|
|
34
|
+
.map((directory) => `${displayPath(join(directory, 'logic.ts'))} -> no test imports this module`);
|
|
35
|
+
|
|
36
|
+
expect(
|
|
37
|
+
failures,
|
|
38
|
+
[
|
|
39
|
+
'Add behavioral tests through each logic module public API.',
|
|
40
|
+
'At minimum cover a documented reference case, boundaries, invalid input and invariants.',
|
|
41
|
+
...failures,
|
|
42
|
+
].join('\n'),
|
|
43
|
+
).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import {
|
|
5
|
+
displayPath,
|
|
6
|
+
findFiles,
|
|
7
|
+
listToolDirectories,
|
|
8
|
+
read,
|
|
9
|
+
repositoryRoot,
|
|
10
|
+
} from './qa-test-helpers';
|
|
11
|
+
|
|
12
|
+
interface VisibleLiteral {
|
|
13
|
+
line: number;
|
|
14
|
+
value: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const domTextAssignment = /\.(?:innerText|textContent|innerHTML)\s*=/;
|
|
18
|
+
const quotedLiteral = /(['"`])((?:\\.|(?!\1).)*)\1/g;
|
|
19
|
+
|
|
20
|
+
function visibleWords(value: string): string {
|
|
21
|
+
return value
|
|
22
|
+
.replace(/\$\{[^}]*\}/g, ' ')
|
|
23
|
+
.replace(/<[^>]+>/g, ' ')
|
|
24
|
+
.replace(/&[a-z]+;/gi, ' ')
|
|
25
|
+
.replace(/[\d\p{P}\p{S}_]+/gu, ' ')
|
|
26
|
+
.replace(/\s+/g, ' ')
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isTechnicalLiteral(value: string): boolean {
|
|
31
|
+
return ['.', '#', '['].some((prefix) => value.startsWith(prefix))
|
|
32
|
+
|| /^data-[a-z0-9-]+$/i.test(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function hardcodedVisibleLiterals(source: string): VisibleLiteral[] {
|
|
36
|
+
const failures: VisibleLiteral[] = [];
|
|
37
|
+
|
|
38
|
+
source.split(/\r?\n/).forEach((line, index) => {
|
|
39
|
+
if (!domTextAssignment.test(line)) return;
|
|
40
|
+
|
|
41
|
+
const assignment = line.slice(line.search(domTextAssignment));
|
|
42
|
+
for (const match of assignment.matchAll(quotedLiteral)) {
|
|
43
|
+
if (isTechnicalLiteral(match[2])) continue;
|
|
44
|
+
const words = visibleWords(match[2]);
|
|
45
|
+
if (words && /\p{L}/u.test(words)) {
|
|
46
|
+
failures.push({ line: index + 1, value: match[2] });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return failures;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function runtimeSources(toolDirectory: string): string {
|
|
55
|
+
return findFiles(toolDirectory, ['.astro', '.ts', '.js'])
|
|
56
|
+
.filter((path) => !path.includes(`${join(toolDirectory, 'i18n')}`))
|
|
57
|
+
.filter((path) => basename(path) !== 'ui.ts')
|
|
58
|
+
.map(read)
|
|
59
|
+
.join('\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('QA: runtime copy is localized', () => {
|
|
63
|
+
const componentFiles = findFiles(join(repositoryRoot, 'src', 'tool'), ['.astro']);
|
|
64
|
+
|
|
65
|
+
it('does not write user-facing string literals directly into the DOM', () => {
|
|
66
|
+
const failures = componentFiles.flatMap((path) =>
|
|
67
|
+
hardcodedVisibleLiterals(read(path)).map(
|
|
68
|
+
({ line, value }) => `${displayPath(path)}:${line} -> ${JSON.stringify(value)}`,
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(
|
|
73
|
+
failures,
|
|
74
|
+
`Move visible browser copy to the tool UI locale contract:\n${failures.join('\n')}`,
|
|
75
|
+
).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('uses every string declared by each UI locale contract', () => {
|
|
79
|
+
const failures: string[] = [];
|
|
80
|
+
|
|
81
|
+
for (const directory of listToolDirectories()) {
|
|
82
|
+
const uiPath = join(directory, 'ui.ts');
|
|
83
|
+
if (!existsSync(uiPath)) continue;
|
|
84
|
+
|
|
85
|
+
const keys = Array.from(read(uiPath).matchAll(/^\s*(\w+)\??:\s*string\s*;/gm), (match) => match[1]);
|
|
86
|
+
const runtime = runtimeSources(directory);
|
|
87
|
+
const unused = keys.filter((key) => !new RegExp(`\\b${key}\\b`).test(runtime));
|
|
88
|
+
|
|
89
|
+
if (unused.length > 0) {
|
|
90
|
+
failures.push(`${displayPath(uiPath)} -> unused: ${unused.join(', ')}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
expect(
|
|
95
|
+
failures,
|
|
96
|
+
`Unused locale keys are dead copy or may mean the browser bypasses translations:\n${failures.join('\n')}`,
|
|
97
|
+
).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -4,8 +4,8 @@ import { textilesCategory } from '../data';
|
|
|
4
4
|
|
|
5
5
|
describe('Tool Validation Suite', () => {
|
|
6
6
|
describe('Library Registration', () => {
|
|
7
|
-
it('should have
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
7
|
+
it('should have 14 tools in ALL_TOOLS', () => {
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(14);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it('textilesCategory should be defined', () => {
|
|
@@ -11,7 +11,7 @@ const burnUI = ui as BurnTestUI;
|
|
|
11
11
|
const fibers = Object.entries(burnUI.fiberData ?? {});
|
|
12
12
|
---
|
|
13
13
|
|
|
14
|
-
<div class="burn-wizard" id="burn-wizard-root">
|
|
14
|
+
<div class="burn-wizard" id="burn-wizard-root" aria-label={burnUI.investigationLabel}>
|
|
15
15
|
<div class="icon-cache" aria-hidden="true">
|
|
16
16
|
{fibers.map(([id, fiber]) => (
|
|
17
17
|
<div id={`icon-cache-${id}`}>
|
|
@@ -135,6 +135,7 @@ const fibers = Object.entries(burnUI.fiberData ?? {});
|
|
|
135
135
|
const name = fiber?.name ?? result.fiberId;
|
|
136
136
|
return `<div class="result-details">
|
|
137
137
|
<p class="result-id-text">${identifiedAs} <strong>${name}</strong>.</p>
|
|
138
|
+
<p class="result-fiber-description" style="color:${fiber?.color ?? 'inherit'}">${fiber?.description ?? ''}</p>
|
|
138
139
|
<div class="result-attrs">
|
|
139
140
|
<div class="attr-row attr-flame">
|
|
140
141
|
<span class="attr-label">${flameLabel}</span>
|
|
@@ -219,4 +220,3 @@ const fibers = Object.entries(burnUI.fiberData ?? {});
|
|
|
219
220
|
|
|
220
221
|
startInit();
|
|
221
222
|
</script>
|
|
222
|
-
|
|
@@ -18,7 +18,7 @@ const toolUI = ui as unknown as EmbroideryStitchPricingEstimatorUI;
|
|
|
18
18
|
<section class="embroider-stage" aria-label={toolUI.stageLabel}>
|
|
19
19
|
<div class="embroider-stage-header">
|
|
20
20
|
<span>{toolUI.canvasAlt}</span>
|
|
21
|
-
<span class="embroider-status" id="embroider-status-badge">
|
|
21
|
+
<span class="embroider-status" id="embroider-status-badge">{toolUI.attentionBadge}</span>
|
|
22
22
|
</div>
|
|
23
23
|
<svg id="embroider-canvas" class="embroider-canvas" viewBox="0 0 400 200" role="img" aria-label={toolUI.canvasAlt}></svg>
|
|
24
24
|
<div class="embroider-stage-caption">
|
|
@@ -78,7 +78,7 @@ const toolUI = ui as unknown as EmbroideryStitchPricingEstimatorUI;
|
|
|
78
78
|
</div>
|
|
79
79
|
<section class="embroider-result" aria-label={toolUI.resultLabel}>
|
|
80
80
|
<div class="embroider-result-main">
|
|
81
|
-
<span class="embroider-eyebrow">{toolUI.
|
|
81
|
+
<span class="embroider-eyebrow">{toolUI.basePriceLabel}</span>
|
|
82
82
|
<strong id="embroider-price">--</strong>
|
|
83
83
|
<span>{toolUI.priceUnit}</span>
|
|
84
84
|
</div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { EmbroideryCalculation, EmbroideryInput } from './logic';
|
|
2
|
-
import {
|
|
2
|
+
import { formatNumber } from './logic';
|
|
3
3
|
import type { EmbroideryStitchPricingEstimatorUI } from './ui';
|
|
4
4
|
import type { EstimateEvaluation } from './evaluator';
|
|
5
5
|
|
|
@@ -15,6 +15,13 @@ function setText(id: string, value: string): void {
|
|
|
15
15
|
if (element) element.textContent = value;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
function formatDuration(value: number, ui: EmbroideryStitchPricingEstimatorUI): string {
|
|
19
|
+
if (value < 60) return `${formatNumber(value, 1)} ${ui.minutesUnit}`;
|
|
20
|
+
const hours = Math.floor(value / 60);
|
|
21
|
+
const minutes = Math.round(value % 60);
|
|
22
|
+
return `${hours} ${ui.hoursUnit} ${minutes} ${ui.minutesUnit}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
function makeSvgElement<K extends keyof SVGElementTagNameMap>(tag: K, attrs: Record<string, string>): SVGElementTagNameMap[K] {
|
|
19
26
|
const element = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
|
20
27
|
Object.entries(attrs).forEach(([key, value]) => element.setAttribute(key, value));
|
|
@@ -55,11 +62,11 @@ function renderValues(calculation: EmbroideryCalculation | null, ui: EmbroideryS
|
|
|
55
62
|
}
|
|
56
63
|
setText('embroider-result-status', ui.estimateBadge);
|
|
57
64
|
setText('embroider-effective', `${formatNumber(calculation.effectiveStitches)} ${ui.stitchesUnit}`);
|
|
58
|
-
setText('embroider-duration',
|
|
65
|
+
setText('embroider-duration', formatDuration(calculation.totalMinutes, ui));
|
|
59
66
|
setText('embroider-price', calculation.basePrice.toFixed(2));
|
|
60
67
|
setText('embroider-area', `${formatNumber(calculation.areaCm2, 1)} cm2`);
|
|
61
|
-
setText('embroider-stitch-time',
|
|
62
|
-
setText('embroider-setup-time',
|
|
68
|
+
setText('embroider-stitch-time', formatDuration(calculation.stitchMinutes, ui));
|
|
69
|
+
setText('embroider-setup-time', formatDuration(calculation.setupMinutes, ui));
|
|
63
70
|
setText('embroider-colour-changes', formatNumber(calculation.colourChanges));
|
|
64
71
|
}
|
|
65
72
|
|
|
@@ -90,5 +97,5 @@ export function renderEmbroidery(ctx: RenderContext): void {
|
|
|
90
97
|
|
|
91
98
|
export function copyableSummary(ctx: RenderContext): string {
|
|
92
99
|
if (!ctx.calculation) return ctx.ui.invalidMessage;
|
|
93
|
-
return `${ctx.ui.resultLabel}: ${formatNumber(ctx.calculation.effectiveStitches)} ${ctx.ui.stitchesUnit}, ${
|
|
100
|
+
return `${ctx.ui.resultLabel}: ${formatNumber(ctx.calculation.effectiveStitches)} ${ctx.ui.stitchesUnit}, ${formatDuration(ctx.calculation.totalMinutes, ctx.ui)}, ${ctx.calculation.basePrice.toFixed(2)} ${ctx.ui.priceUnit}`;
|
|
94
101
|
}
|
|
@@ -208,7 +208,7 @@ const calcUI = ui as FabricProjectCalculatorUI;
|
|
|
208
208
|
markEl.className = 'fpc-ruler-mark';
|
|
209
209
|
markEl.style.top = `${(mark / totalMeters) * 100}%`;
|
|
210
210
|
const label = document.createElement('span');
|
|
211
|
-
label.textContent = `${mark.toFixed(step < 0.5 ? 2 : 1)}
|
|
211
|
+
label.textContent = `${mark.toFixed(step < 0.5 ? 2 : 1)}${window.__toolUI.resultUnit}`;
|
|
212
212
|
markEl.appendChild(label);
|
|
213
213
|
ruler.appendChild(markEl);
|
|
214
214
|
mark = parseFloat((mark + step).toFixed(3));
|
|
@@ -222,9 +222,11 @@ const calcUI = ui as FabricProjectCalculatorUI;
|
|
|
222
222
|
renderRuler(meters);
|
|
223
223
|
const desc = document.getElementById('board-desc');
|
|
224
224
|
if (desc) {
|
|
225
|
+
const widthToken = ['{', 'w', '}'].join('');
|
|
226
|
+
const metersToken = ['{', 'm', '}'].join('');
|
|
225
227
|
desc.textContent = window.__toolUI.boardDescFormat
|
|
226
|
-
.replace(
|
|
227
|
-
.replace(
|
|
228
|
+
.replace(widthToken, String(width))
|
|
229
|
+
.replace(metersToken, meters.toFixed(2));
|
|
228
230
|
}
|
|
229
231
|
}
|
|
230
232
|
|
|
@@ -232,14 +234,14 @@ const calcUI = ui as FabricProjectCalculatorUI;
|
|
|
232
234
|
const el = document.getElementById('warning-msg');
|
|
233
235
|
if (!el) return;
|
|
234
236
|
el.style.display = serial ? 'block' : 'none';
|
|
235
|
-
if (serial) el.textContent = window.__toolUI.warningSerialFormat.replace(
|
|
237
|
+
if (serial) el.textContent = window.__toolUI.warningSerialFormat.replace(String.fromCharCode(123, 119, 125), width);
|
|
236
238
|
}
|
|
237
239
|
|
|
238
240
|
function updateResult(meters: number): void {
|
|
239
241
|
setText('main-meters', meters.toFixed(2));
|
|
240
242
|
const store = shopAdviceMeters(meters);
|
|
241
243
|
const advice = document.getElementById('shop-advice');
|
|
242
|
-
if (advice) advice.textContent = window.__toolUI.shopAdviceFormat.replace(
|
|
244
|
+
if (advice) advice.textContent = window.__toolUI.shopAdviceFormat.replace(String.fromCharCode(123, 109, 125), store.toFixed(2));
|
|
243
245
|
}
|
|
244
246
|
|
|
245
247
|
function updateChecklist(type: string): void {
|
|
@@ -17,7 +17,7 @@ const fibers = Object.entries(fiberData)
|
|
|
17
17
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
18
18
|
---
|
|
19
19
|
|
|
20
|
-
<div class="fabric-truth-container" id="fabric-truth-root">
|
|
20
|
+
<div class="fabric-truth-container" id="fabric-truth-root" aria-label={ui.toolTitle}>
|
|
21
21
|
<div class="card-wrapper">
|
|
22
22
|
<div class="composition-section">
|
|
23
23
|
<h2 class="section-title">
|
|
@@ -98,6 +98,7 @@ const fibers = Object.entries(fiberData)
|
|
|
98
98
|
<div>
|
|
99
99
|
<h4>{ui.careWarning}</h4>
|
|
100
100
|
<p id="care-warning">Sigue siempre las instrucciones de la etiqueta.</p>
|
|
101
|
+
<p class="special-care-warning">{ui.specialCareWarning}</p>
|
|
101
102
|
</div>
|
|
102
103
|
</div>
|
|
103
104
|
|
|
@@ -277,4 +278,3 @@ const fibers = Object.entries(fiberData)
|
|
|
277
278
|
|
|
278
279
|
startInit();
|
|
279
280
|
</script>
|
|
280
|
-
|
|
@@ -62,7 +62,7 @@ const gaugeUI = ui as unknown as KnittingGaugeUI;
|
|
|
62
62
|
</div>
|
|
63
63
|
<div class="input-grid">
|
|
64
64
|
<div class="gauge-field">
|
|
65
|
-
<label for="my-needle">{gaugeUI.labelNeedleMm}</label>
|
|
65
|
+
<label for="my-needle">{gaugeUI.labelNeedleMm} <span class="label-sub">({gaugeUI.labelNeedle})</span></label>
|
|
66
66
|
<input type="number" id="my-needle" class="gauge-input gauge-input-bordered" value="4.0" step="0.25" />
|
|
67
67
|
</div>
|
|
68
68
|
<div class="gauge-field">
|
|
@@ -367,4 +367,3 @@ const gaugeUI = ui as unknown as KnittingGaugeUI;
|
|
|
367
367
|
document.addEventListener('astro:page-load', init);
|
|
368
368
|
init();
|
|
369
369
|
</script>
|
|
370
|
-
|
|
@@ -2,5 +2,5 @@ import type { BibliographyEntry } from '../../types';
|
|
|
2
2
|
|
|
3
3
|
export const bibliography: BibliographyEntry[] = [
|
|
4
4
|
{ name: 'ISO 3758 - Textile Care Symbols', url: 'https://www.iso.org/standard/60465.html' },
|
|
5
|
-
{ name: 'FTC - Textile Care
|
|
5
|
+
{ name: 'FTC - Textile Care Labeling Rule', url: 'https://www.ftc.gov/legal-library/browse/rules/care-labeling-textile-wearing-apparel-certain-piece-goods' },
|
|
6
6
|
];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'BERNINA WeAllSew, Simple Binding Basics',
|
|
6
|
+
url: 'https://weallsew.com/simple-binding-basics/',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'Brother SewingCraft France, Finir un bord de quilt',
|
|
10
|
+
url: 'https://sewingcraft.brother.eu/fr-ch/blog/quilt-club/2024/how-to-bind-a-quilt',
|
|
11
|
+
},
|
|
12
|
+
];
|