cronli5 0.3.1 → 0.3.4

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/CHANGELOG.md CHANGED
@@ -6,6 +6,50 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.4]
10
+
11
+ ### Fixed
12
+
13
+ - **zh:** a bounded *parity* month step dropped its bound — `0 0 1 2-10/2`
14
+ (months 2,4,6,8,10) rendered "每个偶数月" (every even-numbered month), which
15
+ wrongly includes December. zh now enumerates a bounded parity step
16
+ ("2、4、6、8、10月") like en/es/de/fi; the open `*/2`/`2/2` step keeps
17
+ "单数月/偶数月". The exact analogue of the 0.3.3 day-step fix; found by auditing
18
+ the bug class that fix implied (the bounded case was thin in the corpora).
19
+
20
+ ## [0.3.3]
21
+
22
+ ### Fixed
23
+
24
+ - **zh:** a bounded day-of-month step dropped its bounds — `0 0 9-17/2` rendered
25
+ "每2天" (every 2 days) instead of the days 9, 11, 13, 15, 17. It now enumerates
26
+ the days like en/es/de/fi ("每月9、11、13、15、17日"); the open `*/N` step keeps
27
+ "每N天". Surfaced by the new coverage tests (the zh corpus had only open-step
28
+ rows, so the bounded branch was untested).
29
+
30
+ ### Changed
31
+
32
+ - Test coverage closed to its true floor after the Vitest migration: 74 new
33
+ verified corpus rows (core and the English renderer now ~100%), thresholds
34
+ raised to lines 98.5 / branches 97 / functions 99.2 / statements 98.5. The
35
+ remaining uncovered branches are genuinely-unreachable defensive guards.
36
+
37
+ ## [0.3.2]
38
+
39
+ Tooling and docs only — the published library is functionally unchanged.
40
+
41
+ ### Changed
42
+
43
+ - **Test runner migrated to Vitest** (from mocha + c8-over-tsx). V8 coverage is
44
+ source-accurate, so the esbuild/tsx phantom-branch artifact is gone and
45
+ thresholds are gated at the true numbers. (The old c8 figures were wrong in
46
+ both directions — inflated as well as deflated; real coverage is ~98 lines /
47
+ 96 branches / 99 functions, and the genuine untested-source gaps it revealed
48
+ are now tracked in the backlog.)
49
+ - Pruned the shipped rendering items (0.2.0–0.3.1) from the backlog, and ran a
50
+ comment/reference cleanup over the scripts and tooling (stale `IR`→`Schedule`,
51
+ util-split, and mocha/c8 references; process-label scrub; dead code).
52
+
9
53
  ## [0.3.1]
10
54
 
11
55
  ### Changed
package/README.md CHANGED
@@ -330,13 +330,13 @@ required.
330
330
 
331
331
  ## Development
332
332
 
333
- The library has no runtime dependencies. The toolchain (ESLint, Mocha, Chai,
334
- c8, esbuild) lives in `devDependencies`.
333
+ The library has no runtime dependencies. The toolchain (ESLint, Vitest, Chai,
334
+ esbuild) lives in `devDependencies`.
335
335
 
336
336
  ```bash
337
337
  npm install # install dev dependencies (also wires the git hooks)
338
- npm test # run the Mocha test suite (runs against src/, no build needed)
339
- npm run coverage # run tests with c8 coverage and enforce thresholds
338
+ npm test # run the Vitest suite (runs against src/, no build needed)
339
+ npm run coverage # run tests with Vitest V8 coverage and enforce thresholds
340
340
  npm run lint # lint source and tests with ESLint
341
341
  npm run build # emit dist/ (ESM + CJS) and the minified browser global
342
342
  npm run verify # the full CI gate: lint, types, tests, coverage, docs, build
package/dist/lang/zh.cjs CHANGED
@@ -674,13 +674,20 @@ function render(schedule, plan, opts) {
674
674
  opts
675
675
  );
676
676
  }
677
+ function boundedParityMonth(schedule) {
678
+ if (schedule.shapes.month !== "step" || isOpenStep(schedule.pattern.month)) {
679
+ return false;
680
+ }
681
+ const segs = segmentsOf(schedule, "month");
682
+ return segs.length === 1 && segs[0].kind === "step" && segs[0].interval === 2;
683
+ }
677
684
  function monthPhrase(schedule) {
678
685
  if (schedule.pattern.month === "*") {
679
686
  return "";
680
687
  }
681
688
  const segs = segmentsOf(schedule, "month");
682
689
  const first = segs[0];
683
- if (segs.length === 1 && first.kind === "step" && first.interval === 2) {
690
+ if (segs.length === 1 && first.kind === "step" && first.interval === 2 && isOpenStep(schedule.pattern.month)) {
684
691
  return "\u6BCF\u4E2A" + (first.fires[0] % 2 ? "\u5947" : "\u5076") + "\u6570\u6708";
685
692
  }
686
693
  if (segs.length === 1 && first.kind === "range") {
@@ -698,14 +705,14 @@ function monthPhrase(schedule) {
698
705
  }
699
706
  function dayList(schedule) {
700
707
  const segs = segmentsOf(schedule, "date");
701
- if (segs.every((seg) => seg.kind === "single")) {
702
- return segs.map((seg) => seg.value).join("\u3001") + "\u65E5";
708
+ if (segs.every((seg) => seg.kind === "single" || seg.kind === "step")) {
709
+ return fireValues(segs).join("\u3001") + "\u65E5";
703
710
  }
704
711
  return segs.map(function day(seg) {
705
712
  if (seg.kind === "range") {
706
713
  return seg.bounds[0] + "\u65E5\u81F3" + seg.bounds[1] + "\u65E5";
707
714
  }
708
- return seg.value + "\u65E5";
715
+ return seg.kind === "step" ? seg.fires.join("\u3001") + "\u65E5" : seg.value + "\u65E5";
709
716
  }).join("\u3001");
710
717
  }
711
718
  function quartzDate(token, monthPrefix) {
@@ -743,20 +750,20 @@ function datePhrase(schedule) {
743
750
  if (schedule.shapes.date === "quartz") {
744
751
  return quartzDate(date, month || "\u672C\u6708");
745
752
  }
746
- if (schedule.shapes.date === "step") {
753
+ if (schedule.shapes.date === "step" && isOpenStep(date)) {
747
754
  return month + cadence(stepSegment(schedule, "date").interval, "\u5929");
748
755
  }
749
756
  if (!month) {
750
757
  return "\u6BCF\u6708" + dayList(schedule);
751
758
  }
752
- const monthMulti = schedule.shapes.month === "range" || schedule.shapes.month === "list";
759
+ const monthMulti = schedule.shapes.month === "range" || schedule.shapes.month === "list" || boundedParityMonth(schedule);
753
760
  return month + (monthMulti ? "\uFF0C" : "") + dayList(schedule);
754
761
  }
755
762
  function dateCore(schedule, quartzPrefix) {
756
763
  if (schedule.shapes.date === "quartz") {
757
764
  return quartzDate(schedule.pattern.date, quartzPrefix);
758
765
  }
759
- if (schedule.shapes.date === "step") {
766
+ if (schedule.shapes.date === "step" && isOpenStep(schedule.pattern.date)) {
760
767
  return cadence(stepSegment(schedule, "date").interval, "\u5929");
761
768
  }
762
769
  return dayList(schedule);
@@ -837,7 +844,8 @@ function composePoint(schedule, core) {
837
844
  }
838
845
  const dateSet = isSet(schedule.pattern.date);
839
846
  const weekdaySet = isSet(schedule.pattern.weekday);
840
- const comma = dateSet && weekdaySet || schedule.shapes.date === "step";
847
+ const dateCadence = schedule.shapes.date === "step" && isOpenStep(schedule.pattern.date);
848
+ const comma = dateSet && weekdaySet || dateCadence;
841
849
  return qual + (comma ? "\uFF0C" : "") + core;
842
850
  }
843
851
  function composeCadence(schedule, core) {
package/dist/lang/zh.js CHANGED
@@ -648,13 +648,20 @@ function render(schedule, plan, opts) {
648
648
  opts
649
649
  );
650
650
  }
651
+ function boundedParityMonth(schedule) {
652
+ if (schedule.shapes.month !== "step" || isOpenStep(schedule.pattern.month)) {
653
+ return false;
654
+ }
655
+ const segs = segmentsOf(schedule, "month");
656
+ return segs.length === 1 && segs[0].kind === "step" && segs[0].interval === 2;
657
+ }
651
658
  function monthPhrase(schedule) {
652
659
  if (schedule.pattern.month === "*") {
653
660
  return "";
654
661
  }
655
662
  const segs = segmentsOf(schedule, "month");
656
663
  const first = segs[0];
657
- if (segs.length === 1 && first.kind === "step" && first.interval === 2) {
664
+ if (segs.length === 1 && first.kind === "step" && first.interval === 2 && isOpenStep(schedule.pattern.month)) {
658
665
  return "\u6BCF\u4E2A" + (first.fires[0] % 2 ? "\u5947" : "\u5076") + "\u6570\u6708";
659
666
  }
660
667
  if (segs.length === 1 && first.kind === "range") {
@@ -672,14 +679,14 @@ function monthPhrase(schedule) {
672
679
  }
673
680
  function dayList(schedule) {
674
681
  const segs = segmentsOf(schedule, "date");
675
- if (segs.every((seg) => seg.kind === "single")) {
676
- return segs.map((seg) => seg.value).join("\u3001") + "\u65E5";
682
+ if (segs.every((seg) => seg.kind === "single" || seg.kind === "step")) {
683
+ return fireValues(segs).join("\u3001") + "\u65E5";
677
684
  }
678
685
  return segs.map(function day(seg) {
679
686
  if (seg.kind === "range") {
680
687
  return seg.bounds[0] + "\u65E5\u81F3" + seg.bounds[1] + "\u65E5";
681
688
  }
682
- return seg.value + "\u65E5";
689
+ return seg.kind === "step" ? seg.fires.join("\u3001") + "\u65E5" : seg.value + "\u65E5";
683
690
  }).join("\u3001");
684
691
  }
685
692
  function quartzDate(token, monthPrefix) {
@@ -717,20 +724,20 @@ function datePhrase(schedule) {
717
724
  if (schedule.shapes.date === "quartz") {
718
725
  return quartzDate(date, month || "\u672C\u6708");
719
726
  }
720
- if (schedule.shapes.date === "step") {
727
+ if (schedule.shapes.date === "step" && isOpenStep(date)) {
721
728
  return month + cadence(stepSegment(schedule, "date").interval, "\u5929");
722
729
  }
723
730
  if (!month) {
724
731
  return "\u6BCF\u6708" + dayList(schedule);
725
732
  }
726
- const monthMulti = schedule.shapes.month === "range" || schedule.shapes.month === "list";
733
+ const monthMulti = schedule.shapes.month === "range" || schedule.shapes.month === "list" || boundedParityMonth(schedule);
727
734
  return month + (monthMulti ? "\uFF0C" : "") + dayList(schedule);
728
735
  }
729
736
  function dateCore(schedule, quartzPrefix) {
730
737
  if (schedule.shapes.date === "quartz") {
731
738
  return quartzDate(schedule.pattern.date, quartzPrefix);
732
739
  }
733
- if (schedule.shapes.date === "step") {
740
+ if (schedule.shapes.date === "step" && isOpenStep(schedule.pattern.date)) {
734
741
  return cadence(stepSegment(schedule, "date").interval, "\u5929");
735
742
  }
736
743
  return dayList(schedule);
@@ -811,7 +818,8 @@ function composePoint(schedule, core) {
811
818
  }
812
819
  const dateSet = isSet(schedule.pattern.date);
813
820
  const weekdaySet = isSet(schedule.pattern.weekday);
814
- const comma = dateSet && weekdaySet || schedule.shapes.date === "step";
821
+ const dateCadence = schedule.shapes.date === "step" && isOpenStep(schedule.pattern.date);
822
+ const comma = dateSet && weekdaySet || dateCadence;
815
823
  return qual + (comma ? "\uFF0C" : "") + core;
816
824
  }
817
825
  function composeCadence(schedule, core) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cronli5",
3
- "version": "0.3.1",
3
+ "version": "0.3.4",
4
4
  "description": "Cron Like I'm Five: A Cron to English Utility",
5
5
  "repository": {
6
6
  "type": "git",
@@ -65,11 +65,11 @@
65
65
  "lint": "eslint src test cli.js eslint.config.js scripts tooling/scripts",
66
66
  "metamorphic": "node --import tsx tooling/scripts/metamorphic.mjs",
67
67
  "lint:fix": "eslint src test cli.js eslint.config.js scripts tooling/scripts --fix",
68
- "test": "mocha",
68
+ "test": "vitest run",
69
69
  "types": "tsc -p tsconfig.types.json",
70
70
  "test:types": "npm run types && tsd",
71
71
  "typecheck": "tsc -p tsconfig.json",
72
- "coverage": "c8 mocha",
72
+ "coverage": "vitest run --coverage",
73
73
  "verify": "npm run lint && npm run typecheck && npm run test:types && npm test && npm run coverage && npm run conciseness && npm run docs -- --check && npm run build",
74
74
  "prepare": "node scripts/install-hooks.mjs",
75
75
  "prepublishOnly": "npm run lint && npm run typecheck && npm run test:types && npm run build && npm test"
@@ -90,18 +90,18 @@
90
90
  "devDependencies": {
91
91
  "@eslint/eslintrc": "^3.2.0",
92
92
  "@eslint/js": "^9.15.0",
93
- "c8": "^10.1.3",
93
+ "@vitest/coverage-v8": "^4.1.9",
94
94
  "chai": "^4.5.0",
95
95
  "cronstrue": "^3.14.0",
96
96
  "esbuild": "^0.28.1",
97
97
  "eslint": "^9.15.0",
98
98
  "fast-check": "^3.23.1",
99
99
  "globals": "^15.12.0",
100
- "mocha": "^11.0.1",
101
100
  "tsd": "^0.31.2",
102
101
  "tsx": "^4.22.4",
103
102
  "typescript": "^6.0.3",
104
- "typescript-eslint": "^8.61.0"
103
+ "typescript-eslint": "^8.61.0",
104
+ "vitest": "^4.1.9"
105
105
  },
106
106
  "tsd": {
107
107
  "directory": "test-d"
@@ -1041,8 +1041,26 @@ function render(schedule: Schedule, plan: PlanNode, opts: Opts): string {
1041
1041
 
1042
1042
  // --- Day-level qualifier (date / month / weekday / year). ---
1043
1043
 
1044
- // The month phrase: "" (wildcard), "每个奇数月"/"每个偶数月" (step ×2),
1044
+ // Whether the month is a BOUNDED parity step ("2-10/2") an interval-2 step
1045
+ // that does NOT span the open parity set. It enumerates as a list of singles
1046
+ // ("2、4、6、8、10月"), so it takes the multi-month comma like an explicit list,
1047
+ // unlike a single month or a non-parity bounded step ("3-11/3", glued).
1048
+ function boundedParityMonth(schedule: Schedule): boolean {
1049
+ if (schedule.shapes.month !== 'step' ||
1050
+ isOpenStep(schedule.pattern.month)) {
1051
+ return false;
1052
+ }
1053
+
1054
+ const segs = segmentsOf(schedule, 'month');
1055
+
1056
+ return segs.length === 1 && segs[0].kind === 'step' && segs[0].interval === 2;
1057
+ }
1058
+
1059
+ // The month phrase: "" (wildcard), "每个奇数月"/"每个偶数月" (OPEN step ×2),
1045
1060
  // "1月至3月" (range), else the enumerated numbers sharing one 月 ("1、4、7、10月").
1061
+ // A BOUNDED parity step ("2-10/2" = months 2,4,6,8,10) fires a finite set, so
1062
+ // it enumerates through the number path below rather than the open parity class
1063
+ // — the "每个偶数月" wording asserts December too, which the bound excludes.
1046
1064
  function monthPhrase(schedule: Schedule): string {
1047
1065
  if (schedule.pattern.month === '*') {
1048
1066
  return '';
@@ -1051,7 +1069,8 @@ function monthPhrase(schedule: Schedule): string {
1051
1069
  const segs = segmentsOf(schedule, 'month');
1052
1070
  const first = segs[0];
1053
1071
 
1054
- if (segs.length === 1 && first.kind === 'step' && first.interval === 2) {
1072
+ if (segs.length === 1 && first.kind === 'step' && first.interval === 2 &&
1073
+ isOpenStep(schedule.pattern.month)) {
1055
1074
  return '每个' + (first.fires[0] % 2 ? '奇' : '偶') + '数月';
1056
1075
  }
1057
1076
 
@@ -1074,13 +1093,14 @@ function monthPhrase(schedule: Schedule): string {
1074
1093
  return nums.join('、') + '月';
1075
1094
  }
1076
1095
 
1077
- // The day-of-month list. A pure list of singles shares one trailing
1078
- // ("1、3、8日"); any range gives each segment its own 日 ("1至5日、10日").
1096
+ // The day-of-month list. A list of singles or a bounded step enumerated to
1097
+ // its fires (9-17/2 = 9,11,13,15,17) shares one trailing 日 ("1、3、8日",
1098
+ // "9、11、13、15、17日"); any range gives each segment its own 日 ("1至5日、10日").
1079
1099
  function dayList(schedule: Schedule): string {
1080
1100
  const segs = segmentsOf(schedule, 'date');
1081
1101
 
1082
- if (segs.every((seg) => seg.kind === 'single')) {
1083
- return segs.map((seg) => (seg as {value: string}).value).join('、') + '日';
1102
+ if (segs.every((seg) => seg.kind === 'single' || seg.kind === 'step')) {
1103
+ return fireValues(segs).join('、') + '日';
1084
1104
  }
1085
1105
 
1086
1106
  return segs.map(function day(seg) {
@@ -1088,7 +1108,9 @@ function dayList(schedule: Schedule): string {
1088
1108
  return seg.bounds[0] + '日至' + seg.bounds[1] + '日';
1089
1109
  }
1090
1110
 
1091
- return (seg as {value: string}).value + '';
1111
+ return seg.kind === 'step' ?
1112
+ seg.fires.join('、') + '日' :
1113
+ (seg as {value: string}).value + '日';
1092
1114
  }).join('、');
1093
1115
  }
1094
1116
 
@@ -1152,7 +1174,11 @@ function datePhrase(schedule: Schedule): string {
1152
1174
  return quartzDate(date, month || '本月');
1153
1175
  }
1154
1176
 
1155
- if (schedule.shapes.date === 'step') {
1177
+ // An OPEN day step ("*/N") is a frequency — the bare "每N天" cadence. A
1178
+ // BOUNDED step ("a-b/N") fires a finite set of days, so it enumerates them
1179
+ // through the day-list path below, never the cadence (which would drop the
1180
+ // bounds).
1181
+ if (schedule.shapes.date === 'step' && isOpenStep(date)) {
1156
1182
  return month + cadence(stepSegment(schedule, 'date').interval, '天');
1157
1183
  }
1158
1184
 
@@ -1160,12 +1186,13 @@ function datePhrase(schedule: Schedule): string {
1160
1186
  return '每月' + dayList(schedule);
1161
1187
  }
1162
1188
 
1163
- // A multi-month scope (range/list) ends in and would run straight into the
1189
+ // A multi-month scope (range/list, or a bounded parity step that enumerates
1190
+ // like a list — "2、4、6、8、10月") ends in 月 and would run straight into the
1164
1191
  // day — "6月至8月1日" reads "8月1日" as August 1st. The comma keeps the month
1165
1192
  // scope distinct from the day ("6月至8月,1日"). A single month stays glued
1166
1193
  // ("6月1日"), which is unambiguous.
1167
1194
  const monthMulti = schedule.shapes.month === 'range' ||
1168
- schedule.shapes.month === 'list';
1195
+ schedule.shapes.month === 'list' || boundedParityMonth(schedule);
1169
1196
 
1170
1197
  return month + (monthMulti ? ',' : '') + dayList(schedule);
1171
1198
  }
@@ -1178,7 +1205,9 @@ function dateCore(schedule: Schedule, quartzPrefix: string): string {
1178
1205
  return quartzDate(schedule.pattern.date, quartzPrefix);
1179
1206
  }
1180
1207
 
1181
- if (schedule.shapes.date === 'step') {
1208
+ // An open day step is the bare "每N天" cadence; a bounded step enumerates its
1209
+ // days through dayList (see datePhrase), so the bounds are not dropped.
1210
+ if (schedule.shapes.date === 'step' && isOpenStep(schedule.pattern.date)) {
1182
1211
  return cadence(stepSegment(schedule, 'date').interval, '天');
1183
1212
  }
1184
1213
 
@@ -1318,7 +1347,12 @@ function composePoint(schedule: Schedule, core: string): string {
1318
1347
 
1319
1348
  const dateSet = isSet(schedule.pattern.date);
1320
1349
  const weekdaySet = isSet(schedule.pattern.weekday);
1321
- const comma = dateSet && weekdaySet || schedule.shapes.date === 'step';
1350
+ // The comma separates an OR union or the open "每N天" cadence from the core. A
1351
+ // bounded date step renders as a glued day list ("每月9、11…日"), not a
1352
+ // cadence, so it takes no comma — only an open step does.
1353
+ const dateCadence = schedule.shapes.date === 'step' &&
1354
+ isOpenStep(schedule.pattern.date);
1355
+ const comma = dateSet && weekdaySet || dateCadence;
1322
1356
 
1323
1357
  return qual + (comma ? ',' : '') + core;
1324
1358
  }